在兩者的選擇中,最後還是選了 session。
而這次,我有了充分的理由不選JWT。
額外的傳輸成本
JWT 除了作為登入憑證,也能在 Payload 中攜帶資訊。加上 Header 和 Signature,整個 Token 通常會比單純的 Session ID 胖上一些。
當然,Payload 變長不代表簽章也會跟著變長;但放進去的資訊越多,每次請求要傳送的 Token 就可能越大。
既然這次沒有非得把這些資訊帶在身上的需求,那這份額外的成本似乎也沒必要。
沒有跨服務的需求
這次的新版數位校園,目前沒有讓其他服務獨立驗證 Token 的需求,也沒有什麼非得塞進 Token 的使用者資訊。
如果只是要在前端顯示使用者身分,設置一個 /me 端點就能處理。當然,JWT 也可以搭配 /me,也不是只有跨服務才能用;只是對這次的專案來說,我實在找不到非用它不可的理由。
撤銷不易
JWT 如果只靠簽章和到期時間驗證,發出去之後,就得等它自然過期;想提前作廢,還得另外設計撤銷機制。
Stateless 是它的優勢,但在此刻卻變成了它的劣勢。
我也曾試過在資料庫的 User 加一個 token_ver 欄位,並在 JWT 的 Payload 中另外放入 ver。
驗證時比對兩者的版本是否匹配;想讓舊 Token 失效,就把資料庫中的 token_ver 加一。如此一來,舊版本便會對不上,也就達成了撤銷的效果。
不過,這招通常會讓該使用者同一版本的 Token 一起失效。如果想單獨撤銷某一個 Token,還得另外設計。
但這下好了,撤銷的問題解決了,但每次驗證又得查詢伺服器端狀態。原本看中的無狀態優勢,在我的實作裡也就沒了。
既然都得維護伺服器端狀態,對這次的專案而言,直接用 Session 反而比較乾脆。
前端使用上的取捨
JWT 的 Payload 能攜帶資訊,如果能直接讓 JS 讀取,感覺不用實在有點可惜。
但如果想在前端直接取得、解碼整個 Token,就得讓 JS 能碰到它;這時候要怎麼保存 Token,以及發生 XSS 時憑證會不會被偷走,就成了另一個需要考慮的問題。
當然,JWT 也完全可以放在 HttpOnly Cookie 裡,再透過 /me 取得前端需要的資料。只是這樣一來,對目前的新版數位校園而言,我又何必特地選 JWT 呢?
食之無味,棄之可惜。
兩個字!
雞肋!
雞肋!
呦你醒啦,正要叫你的說。
賴個床嘛,但剛剛那段我有聽到。
只能說,在放下對 Session 的偏見後,回頭看自己以前做過的作品,確實有不少情境,Session 反而可能是更合適的選擇。
所以,今天總算可以開始寫 Code 了吧?
可以是可以,不過要讓伺服器記住你,總得先想想 Session 要存在哪裡吧。
啊?你不是說選完就能開工了嗎?
對啊,這不就要開工了嗎?先來寫個存 Session 的資料表。
Table sessions {
id uuid [pk]
token_hash varchar [unique, not null]
account_id uuid [ref: > accounts.id, not null]
created_at timestamp [not null]
last_used_at timestamp [not null]
expires_at timestamp [not null]
revoked_at timestamp
}
好先讓 Agent 把這個 Table 整合進去吧。

ok,看一下成果。
class SessionService:
"""Persist and manage hashed account-session tokens."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create(
self, *, account_id: UUID, token_hash: str, expires_at: datetime
) -> Session:
...略
return model
async def get(self, session_id: UUID) -> Session:
...略
return model
async def get_by_token_hash(self, token_hash: str) -> Session:
...略
return model
async def get_active_by_token_hash(
self, token_hash: str, *, now: Optional[datetime] = None
) -> Session:
...略
return model
async def list_for_account(
self, account_id: UUID, *, limit: int = 100, offset: int = 0
) -> list[Session]:
...略
return list(result)
async def update(
self,
session_id: UUID,
*,
expires_at: datetime | object = _UNSET,
last_used_at: datetime | object = _UNSET,
revoked_at: Optional[datetime] | object = _UNSET,
) -> Session:
...略
return model
async def touch(self, session_id: UUID, *, used_at: Optional[datetime] = None) -> Session:
return await self.update(
session_id,
last_used_at=_utc_naive(used_at)
or datetime.now(timezone.utc).replace(tzinfo=None),
)
async def revoke(
self, session_id: UUID, *, revoked_at: Optional[datetime] = None
) -> Session:
return await self.update(
session_id,
revoked_at=_utc_naive(revoked_at)
or datetime.now(timezone.utc).replace(tzinfo=None),
)
async def delete(self, session_id: UUID) -> None:
...略
await _commit(self.session)
我看一下,嗯,有幾個函式是可以刪掉的,這樣也能減少後續 Agent 誤用的機會。
touch 要刪掉,delete 也要刪掉,get_by_token_hash 也刪掉吧。
為什麼要刪這三個?這三個不是各自有它們的功能嗎?
你說得對,它們確實各自有功能。
但資料庫「做得到」,不代表 Service 就一定要把每種操作都給出去。
先看 touch。在我們的設計中,每次更新 last_used_at 的同時,也需要更新 expires_at 來完成續期。既然這兩件事本來就應該一起發生,單獨提供一個只更新 last_used_at 的 touch,反而容易讓人誤以為呼叫它就完成了整個續期流程。
所以這裡先拿掉,需要時直接明確地更新兩個欄位。
再來是 delete。
我們已經有 revoke 可以讓一個 Session 失效,目前也沒有真的需要把 Session 紀錄直接從資料庫刪掉的業務需求。既然如此,就乾脆不要提供 delete,也省得之後維護時不小心用錯。
最後是 get_by_token_hash。
這個函式只要 token_hash 對得上,就會把 Session 找出來,不管它已經過期,還是早就被撤銷了。
但目前所有需要透過 Token 找 Session 的地方,我們要的其實都是「有效的 Session」。
既然已經有 get_active_by_token_hash 會順便檢查 revoked_at 和 expires_at,那目前就沒有留下前者的必要。
好複雜的想法。
其實也沒那麼複雜。
簡單來說,就是用不到的東西先不要留,能少一種用法,就少一種用錯的可能。
少做少錯?
是這樣沒錯。
好啦,Session 存的地方有了,Service 也整理完了。
這次真的可以來寫登入了。

這邊我讓它先寫了 routes/auth.py,讓使用者可以進行基本的登入,然後寫 routes/user.py 讓使用者可以更改自己的密碼。
class AccountProfile(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
account: str
position_id: UUID
group_id: UUID
display_name: str | None
is_active: bool
created_at: datetime
updated_at: datetime
欸這契約好像怪怪的。
position_id 跟 group_id 對吧。
你給前端這個沒有用啊,人類不可讀阿。
確實,改一下。
class AccountProfile(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
account: str
position_name: str
group_name: str
display_name: str | None
is_active: bool
created_at: datetime
updated_at: datetime
這樣好多了。
async def get_current_principal(
credentials: Annotated[
HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)
],
database_session: Annotated[AsyncSession, Depends(get_session)],
) -> AuthenticatedPrincipal:
"""Resolve an active Bearer token to its enabled account and persisted session."""
...略
await sessions.touch(current_session.id)
return AuthenticatedPrincipal(
account=account,
session=current_session,
position_name=position.name,
group_name=group.name,
)
等一下這裡怎麼有 sessions.touch,靠北只記得講,忘記刪了。
但也證明了我剛剛說要刪的決定是對的,真的很容易誤用。
算了改寫邏輯吧。

歐?這裡 Agent 說只有滑動的 expires_at,要給我加一個 force_expires_at。
你本來是打算用
last_used_at去計算對吧。
對,但它這樣做也行。
那一不做二不休,除了會隨著操作往後延的 expires_at,再給 Session 一條絕對不能超過的期限吧。
簡單來說,expires_at 負責回答「你多久沒出現,我就忘記你」;force_expires_at 則負責回答「就算你一直出現,我最晚什麼時候還是得叫你重新登入」。
這樣一來,Session 可以在使用者持續操作時續期,卻不會因此一路活到天荒地老。
所以你現在記住我了?
理論上,是。
理論上?
畢竟今天光是決定要怎麼記住你,就已經一路從 JWT 講到 Session、從資料表講到登入,再從滑動期限講到絕對期限了。
至於它實際跑起來,到底是不是真的記得住你——
明天再來驗收。
……所以你今天還是沒辦法保證?
沒關係,至少我已經用文字記住現在的我們了。